一直以來,多數的 PHP 應用程式都是阻塞式地執行。當 PHP 程式遇到如資料庫查詢或檔案讀寫等操作時,它會等待這些動作完成才繼續執行下一行程式碼。在較高並行處理的需求下,這種方式可能會導致資源的浪費,降低伺服器的響應速度。
Coroutine(共常式、協程)是一種非阻塞的程式設計機制,它讓開發人員能夠在單一的執行緒中,使多個函式能夠彼此進行中斷和恢復執行。Coroutine 的核心概念指的是,遇到某些需要等待時間的系統動作,如果我們能釋放當下的執行資源,就可以讓其他程式先執行。藉此,能夠增加系統對任務消化量,不浪費任何的等待時間。
Coroutine 概念說明
如上圖所示,在單一執行緒中若 Function A 進入了不需要資源的狀態,比如:等待資料庫查詢時間、等待外部 API 反應,或者是進入 Sleep 等狀態,那麼它將會釋放出目前的資源,給予 Function B 使用。在這個概念下,與傳統的多執行緒或多程序相比,Coroutine 提供了一種更為簡單同時執行多任務的方式。
你可以在你的開發環境中建立這個檔案 sleep.php
:
<?php
for ($i=0; $i < 10; $i++) {
sleep(1);
echo "First Coroutine\n";
}
for ($i=0; $i < 5; $i++) {
sleep(2);
echo "Second Coroutine\n";
}
不用多說,這段程式碼的執行方式顯而易見。
當你執行上述 sleep.php
程式碼時,它首先會以每次間隔一秒地時間,完整地印出「First Coroutine」十次;在這之後,它才會以每次間隔兩秒的時間印出「Second Coroutine」五次,整體花費了 20 秒。
對於 PHP 來說,即使第二個 for 迴圈可能沒有必要等待第一個 for 迴圈完成,但由於 PHP 的同步性質,它必須等待上一組程式完成才能輪到它執行。
你可以試著執行看看:
Swow 是一個以 C 語言製作而成的 PHP Coroutine 引擎,它主打的是你能夠在幾乎保有原有開發習慣的方式下,使你的 PHP 應用程式更加地高效率。它最大限度地保持了 PHP 原生方法的使用方式,你能透過官方介紹來更加了解這個引擎。
讓我們透過簡單的範例來展示 Coroutine 的執行,請在你的開發環境下建立一個 coroutine.php
:
<?php
require './vendor/autoload.php';
use Swow\Coroutine;
use function Swow\Sync\waitAll;
Coroutine::run(function () {
for ($i=0; $i < 10; $i++) {
sleep(1);
echo "First Coroutine\n";
}
});
Coroutine::run(function () {
for ($i=0; $i < 5; $i++) {
sleep(2);
echo "Second Coroutine\n";
}
});
waitAll();
當你執行 coroutine.php
程式碼時,你會發現這兩個 Coroutine::run
幾乎同時執行。這是因為 Swow 的 Coroutine 允許這兩個功能彼此獨立運作,但仍然在同一個執行緒中。當 First Coroutine 在遇到 sleep(1)
時,控制權立即被交給 Second Coroutine。這樣的交替運行方式讓這兩個功能能夠幾乎同時執行,並在大約 10 秒後完成,這比之前的 20 秒要短得多。
這個範例以簡單地方式展示了 Coroutine 是如何使多個任務在同一執行緒中幾乎同時執行,從而使你的 PHP 應用程式提高執行效率。如果你需要更加深入的討論,你可以參考 Swow 官方的教學,來了解更深入的知識。
上一章,我們以簡單的方式完成了一個簡易的 Workerman HTTP 伺服器,雖然長時間留存在記憶體中的 PHP 應用程式可以減少 PHP 解釋器重複編譯程式碼的時間,但伺服器的執行卻有可以能被 PHP 逐行阻塞執行的模式給拖累。
你可以修改一下上一章的範例,讓這一段 $httpWorker->onMessage()
變成這個樣子:
$httpWorker->onMessage = static function (TcpConnection $connection, Request $request) {
$isSleep = $request->get(name: 'is_sleep', default: 'no');
if($isSleep === "yes") {
$start = microtime(true);
sleep(10);
$connection->send('sleep 10s');
$end = microtime(true);
$time = $end - $start;
print_r(sprintf(
"[%s] %s%s, %.2fms\n",
$request->method(),
$request->host(),
$request->uri(),
$time * 1000
));
} else {
$start = microtime(true);
$connection->send('hello world');
$end = microtime(true);
$time = $end - $start;
print_r(sprintf(
"[%s] %s%s, %.2fms\n",
$request->method(),
$request->host(),
$request->uri(),
$time * 1000
));
}
};
上述程式碼會判斷 URL Query 中是否包含 is_sleep
參數,如果參數為 yes
則會觸發 sleep 10 秒的方法。修改完畢後你可以打開 Postman 並以 http://localhost:8085/?is_sleep=yes
觸發:
直到十秒計時時間到,才正確地收到響應。
你可以再試試看,若是在存取 http://localhost:8085/?is_sleep=yes
時,再另外打開一個 Postman Tab 存取 http://localhost:8085/
,於 is_sleep
後觸發的請求將等待上一個請求存取完成後才隨之完成響應。
這是因為只有一個 PHP Process 的 Workerman Server 在遇到阻塞的 sleep(10)
時只能持續等待當前任務完成,才能夠接收下一個情求進行處理。
這時後如果能夠搭配 Coroutine 是不是代表我們有機會在同一個執行緒上以非阻塞的方式執行更多請求?
坐而言不如起而行,讓我們來試試看吧!在你的開發環境中建立 non-blocking-server.php
:
<?php
require_once './init.php';
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Workerman\Protocols\Http\Request;
use Swow\Coroutine;
use System\SwowDriver;
// #### http worker ####
$httpWorker = new Worker('http://0.0.0.0:8080');
$httpWorker->onMessage = static function (TcpConnection $connection, Request $request) {
Coroutine::run(static function () use ($connection, $request) : void {
$isSleep = $request->get(name: 'is_sleep', default: null);
if($isSleep !== null) {
$start = microtime(true);
sleep(10);
$connection->send('sleep 10s');
$end = microtime(true);
$time = $end - $start;
print_r(sprintf(
"[%s] %s%s, %.2fms\n",
$request->method(),
$request->host(),
$request->uri(),
$time * 1000
));
} else {
$start = microtime(true);
$connection->send('hello world');
$end = microtime(true);
$time = $end - $start;
print_r(sprintf(
"[%s] %s%s, %.2fms\n",
$request->method(),
$request->host(),
$request->uri(),
$time * 1000
));
}
});
};
$httpWorker::$eventLoopClass = SwowDriver::class;
// Run all workers
Worker::runAll();
上述的程式碼引用了兩個關鍵部分,分別是 use Swow\Coroutine;
與 use System\SwowDriver;
,前者將 Coroutine
的相關方法帶入了程式中,後者則是將基於 Swow Coroutine 引擎的 EventLoopClass
取代了 Workerman 內建的事件循環類別。
再細看程式碼的設計:
$httpWorker->onMessage = static function (TcpConnection $connection, Request $request) {
Coroutine::run(static function () use ($connection, $request) : void {
//hide
});
};
我們可以預期,每一個連線進入 PHP Process 後就會立即啟動與其他請求相異的 Coroutine,當其他請求進入忙碌的狀態,執行緒將會立刻被釋放出來,將形成非阻塞的單執行緒執行效果。
讓我們透過 php non-blocking-server.php start
執行伺服器:
你會發現,右上角的 Event-Loop
被置換成基於 Swow 引擎的 System\SwowDriver
,如果你對這個驅動程式的實作方式感到興趣,你可以至這個 Github issue 討論串進行了解。
接著讓我們回到 Postman 中以 http://localhost:8085/?is_sleep=yes
進行存取,再打開其他 Tab 存取 http://localhost:8085
,無論如何請求我們的伺服器將不再阻塞。
透過 Swow 引擎的協助,我們將能夠輕鬆地在 PHP 程式語言中實現 Coroutine 的效果。在這種模式下,我們不再受到 PHP 的阻塞執行方式所限制,可以充分地利用資源,提高應用程式的響應速度和效能。
透過本篇文章的例子,我們可以對 Coroutine 的工作方式有一個直觀的了解。而 SWOW 和 Workerman 的結合,則為 PHP 的開發提供了一種新的選擇。